Skip to content

Ask Composer's autoloaders for the file instead of letting them include it - #6431

Merged
staabm merged 2 commits into
phpstan:2.3.xfrom
theodorejb:fix-gh-15184
Oct 16, 2026
Merged

staabm merged 2 commits into
phpstan:2.3.xfrom
theodorejb:fix-gh-15184

Conversation

@theodorejb

@theodorejb theodorejb commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

AutoloadSourceLocator finds which file declares a class by running the registered autoloaders behind FileReadTrapStreamWrapper, which records the path an include reached for and serves an empty script in its place. That only shadows the real file while the compiler asks the wrapper for the contents. When OPcache already holds the script, it does not ask, and the file runs a second time:

Child process error (exit code 255): Fatal error: Cannot redeclare function Psl\Type\optional()
(previously declared in C:\myProject\vendor\php-standard-library\type\src\Psl\Type\optional.php:18)
in C:\myProject\vendor\php-standard-library\type\src\Psl\Type\optional.php on line 18

That is fatal for any file that declares a function, which is how php-standard-library and azjezz/psl lay out their code: one function per file. Their files-autoload bootstrap has already loaded every path that their PSR-4 prefix also resolves to.

2.2.13 exposed this by starting spawned workers with OPcache on (ProcessHelper::resolveWorkerIniEntries()), and it still reproduces on 2.3.1. Windows has no pcntl_fork(), so its workers are always spawned.

Fix

Before an autoloader that is a Composer ClassLoader runs inside the trap, AutoloadSourceLocator asks its findFile() for the path. findFile() answers with the same path loadClass() would include, without running anything, so nothing is compiled and no cache is consulted. The check sits in the existing loop over the autoloaders, so registration order is unchanged, and every other autoloader still runs in the trap as before.

findFile() joins the mapped prefix to the rest of the name, so its answer can contain ../ segments and mixed separators. It is resolved with realpath(), as an include would resolve it, because locateIdentifier() compares the located path with ReflectionClass::getFileName().

A path behind a stream wrapper is left to the trap, because realpath() cannot resolve it. The phar's own autoloader is such a ClassLoader: it maps PHPStan\ to phar://.../src.

In the phar

php-scoper prefixes the new use Composer\Autoload\ClassLoader;, but Box leaves the class itself unprefixed, so in the phar the instanceof would never match. compiler/build/scoper.inc.php already removes that prefix in a fixed list of files, and AutoloadSourceLocator.php is now on that list.

Checksum PHAR is expected to fail, because this PR changes how that one file is scoped.

Tests

e2e/bug-15184 has the same layout as the report: a function-per-file module loaded through Composer's files autoload, in a directory its PSR-4 prefix also maps, and code that uses the function's name as a class. It runs with --debug and with pcntl_fork disabled. Forked workers switch OPcache off, so a default run on Linux would not reach the bug. On Windows, the spawned-worker run crashes with Cannot redeclare function on 2.3.x and passes with this PR.

The phar case needs no new test: the existing extension-tests jobs cover it. They failed with Call to method yes() on an unknown class PHPStan\TrinaryLogic. on an earlier revision of this PR that had no fallback for paths behind a stream wrapper.

Also checked by hand on Windows with PHP 8.5, against a project on php-standard-library/type that analyses return $o instanceof \Psl\Type\optional;:

  • the 2.3.1 phar: Cannot redeclare function Psl\Type\optional()
  • this PR from source: class.notFound on the instanceof line, no fatal

Alternatives considered

Earlier revisions of this PR kept the include and tried to make it safe. The first called opcache_invalidate() from the trap's stream_open() for the path being opened. Its CI run was cut short by the next push, so it was neither confirmed nor ruled out. The next refused the open for a file already in get_included_files(), and failed the Symplify integration job.

#6742 invalidates every included file before any autoloader runs, which also covers autoloaders that are not a Composer ClassLoader. This PR does not cover those. The two approaches could be combined, with invalidation only before the autoloaders that are not a ClassLoader.

Closes phpstan/phpstan#15184

🤖 Generated with Claude Code

@theodorejb theodorejb changed the title Evict the OPcache entry for a trapped path before the autoloader includes it Refuse the trap's open for a file the process has already run Sep 13, 2026
@theodorejb
theodorejb marked this pull request as draft September 13, 2026 03:57
@theodorejb theodorejb changed the title Refuse the trap's open for a file the process has already run Ask Composer's autoloaders for the file instead of letting them include it Sep 13, 2026
@theodorejb
theodorejb marked this pull request as ready for review September 13, 2026 04:39
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@SanderMuller

Copy link
Copy Markdown
Contributor

The fix works from source, but in the phar the new fast path never runs, so the fatal from phpstan/phpstan#15184 is still there.

php-scoper prefixes the new import. The phar that CI built for this PR (artifact phar-file of run 34738406883) has:

use _PHPStan_6c01f7a60\Composer\Autoload\ClassLoader;

The project's autoloader is an unprefixed Composer\Autoload\ClassLoader, so the instanceof check is always false and every autoloader still goes through the trap. compiler/build/scoper.inc.php already has a patcher that removes this prefix, but only in bin/phpstan, TestCaseSourceLocatorFactory.php, PHPStanTestCase.php and BetterReflection's ComposerSourceLocator.php. I think AutoloadSourceLocator.php needs to go on that list.

Repro on macOS, PHP 8.5.8: a project with php-standard-library/type 6.x, level 5, analysing one file that does return $o instanceof \Psl\Type\optional;. With --debug:

  • 2.2.15 phar: Fatal error: Cannot redeclare function Psl\Type\optional()
  • this PR's CI phar: the same fatal
  • this PR's CI phar with only that import unprefixed: class.notFound on the instanceof line, no fatal

On the PR head from source:

  • testTrapSurvivesOpcacheCacheHit passes. With the base AutoloadSourceLocator.php it fails with Cannot redeclare function OpcacheTrap\thing(), so the test covers the bug. It cannot catch the scoping problem, because it does not run from the phar.
  • The SourceLocator tests pass (73 tests), and phpcs is clean on the touched files. make phpstan reports one error, at tests/PHPStan/Type/ObjectTypeTest.php:803, which this PR does not touch.
  • I suspected that realpath() changes the located path for a package installed through a symlink, as a Composer path repository does. It does not: the base and the PR both return the resolved target.

All five red checks are also red on #6564, another 2.2.x PR.

I did not run the Windows setup from the issue, and I did not measure performance.

@theodorejb

theodorejb commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

@SanderMuller Thanks for finding the phar issue. I rebased and updated the commit to add AutoloadSourceLocator.php to the list of files the ClassLoader patcher un-prefixes.

In regards to performance, two A/B runs each of PHPStan's own src came out within noise (70–72s with the change, 72–76s without), and the changed path only runs about 50 times per worker.

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that fixes it. With the phar CI built from bed3f0c, the php-standard-library/type repro now reports class.notFound instead of the fatal, with and without OPcache. The new build test fails without the entry, and the phar test fails without the :// guard.

On a real project of about 4,500 files the reported errors are identical. CPU did not go up: median 140.2s before and 131.4s after, over 3 cold runs each. The full suite and make phpstan pass locally. The red Checksum PHAR job is expected here: the only file that differs between the base and PR checksum phars is AutoloadSourceLocator.php.

Comment thread compiler/build/scoper-namespaces.php Outdated
…de it

AutoloadSourceLocator finds the file that declares a class by running the
autoloaders inside FileReadTrapStreamWrapper, which serves an empty script for
each file they include. When OPcache already holds the script, the include runs
the cached copy instead, so a file the process already ran executes again. A
file declaring a function then dies with "Cannot redeclare". That is the
function-per-file layout of php-standard-library, whose "files" autoload loads
paths that its PSR-4 prefix also resolves to.

Before running a Composer ClassLoader in the trap, ask its findFile() for the
path, so nothing is included. Other autoloaders, and paths behind a stream
wrapper such as the phar's own phar://, still go through the trap.

In the phar, php-scoper prefixes the ClassLoader import but Box leaves the class
unprefixed, so AutoloadSourceLocator.php joins the files where scoper.inc.php
removes that prefix again.

Closes phpstan/phpstan#15184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@staabm
staabm requested review from SanderMuller and a balanced review from Copilot and removed request for SanderMuller October 16, 2026 13:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No blocking issues remain; the file-only fixture bypasses result caching, so both regression invocations perform analysis.

0 open findings

What changed in this PR

Avoids OPcache redeclaration crashes by locating Composer-managed source files without including them.

Changes:

  • Uses Composer’s findFile() while preserving autoloader order and stream-wrapper fallback.
  • Adjusts PHAR scoping for the Composer class reference.
  • Adds regression coverage for debug and spawned-worker analysis.
File Description
src/​Reflection/​BetterReflection/​SourceLocator/​AutoloadSourceLocator.php Resolves Composer paths without inclusion.
e2e/​bug-15184/​test.php Exercises a function name used as a class.
e2e/​bug-15184/​src/​Type/​optional.php Supplies the function-only fixture.
e2e/​bug-15184/​phpstan.dist.neon Requires the expected unknown-class diagnostic.
e2e/​bug-15184/​composer.json Configures overlapping files and PSR-4 autoloading.
e2e/​bug-15184/​.gitignore Excludes generated dependencies.
compiler/​build/​scoper.inc.php Preserves the unprefixed Composer reference.
.github/​workflows/​e2e-tests.yml Runs both regression modes with OPcache available.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified the test and I think it makes sense.

@staabm
staabm requested a review from VincentLanglet October 16, 2026 13:20
Comment thread .github/workflows/e2e-tests.yml Outdated
Co-authored-by: Markus Staab <maggus.staab@googlemail.com>
@staabm
staabm merged commit e4f45f9 into phpstan:2.3.x Oct 16, 2026
970 of 975 checks passed
@staabm

staabm commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Thank you!

@theodorejb
theodorejb deleted the fix-gh-15184 branch October 16, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2.2.13: "Cannot redeclare function" fatal error with php-standard-library/type package

6 participants